Italiano

Guida completa ai generics di TypeScript: sintassi, vantaggi e best practice per gestire tipi di dati complessi in applicazioni software globali.

Generics di TypeScript: Padroneggiare Tipi di Dati Complessi per Applicazioni Robuste

TypeScript, un superset di JavaScript, permette agli sviluppatori di scrivere codice più robusto e manutenibile attraverso la tipizzazione statica. Tra le sue funzionalità più potenti ci sono i generics (o tipi generici), che consentono di scrivere codice in grado di funzionare con una varietà di tipi di dati, mantenendo al contempo la sicurezza dei tipi. Questa guida offre un'esplorazione completa dei generics di TypeScript, concentrandosi sulla loro applicazione a tipi di dati complessi nel contesto dello sviluppo software globale.

Cosa sono i Generics?

I generics forniscono un modo per scrivere codice riutilizzabile che può funzionare con tipi diversi. Invece di scrivere funzioni o classi separate per ogni tipo che si desidera supportare, è possibile scrivere una singola funzione o classe che utilizza parametri di tipo. Questi parametri di tipo sono segnaposto per i tipi effettivi che verranno utilizzati quando la funzione o la classe viene chiamata o istanziata. Ciò è particolarmente utile quando si ha a che fare con strutture di dati complesse in cui il tipo di dati all'interno di tali strutture può variare.

Vantaggi dell'utilizzo dei Generics

Sintassi di Base dei Generics

La sintassi di base dei generics prevede l'uso di parentesi angolari (< >) per dichiarare i parametri di tipo. Questi parametri di tipo sono tipicamente chiamati T, K, V, ecc., ma è possibile utilizzare qualsiasi identificatore valido. Ecco un semplice esempio di una funzione generica:


function identity<T>(arg: T): T {
  return arg;
}

let myString: string = identity<string>("hello");
let myNumber: number = identity<number>(123);
let myBoolean: boolean = identity<boolean>(true);

console.log(myString); // Output: hello
console.log(myNumber); // Output: 123
console.log(myBoolean); // Output: true

In questo esempio, <T> dichiara un parametro di tipo chiamato T. La funzione identity accetta un argomento di tipo T e restituisce un valore di tipo T. Quando si chiama la funzione, è possibile specificare esplicitamente il parametro di tipo (es. identity<string>) o lasciare che TypeScript lo inferisca in base al tipo dell'argomento.

Lavorare con Tipi di Dati Complessi

I generics diventano particolarmente preziosi quando si ha a che fare con tipi di dati complessi come array, oggetti e interfacce. Esploriamo alcuni scenari comuni:

Array Generici

È possibile utilizzare i generics per creare funzioni o classi che funzionano con array di tipi diversi:


function arrayToString<T>(arr: T[]): string {
  return arr.join(", ");
}

let numberArray: number[] = [1, 2, 3, 4, 5];
let stringArray: string[] = ["apple", "banana", "cherry"];

console.log(arrayToString(numberArray)); // Output: 1, 2, 3, 4, 5
console.log(arrayToString(stringArray)); // Output: apple, banana, cherry

Qui, la funzione arrayToString accetta un array di tipo T[] e restituisce una rappresentazione in stringa dell'array. Questa funzione funziona con array di qualsiasi tipo, rendendola altamente riutilizzabile.

Oggetti Generici

I generics possono anche essere usati per definire funzioni o classi che funzionano con oggetti di forme diverse:


interface Person {
  name: string;
  age: number;
  country: string; // Aggiunto 'country' per il contesto globale
}

interface Product {
  id: number;
  name: string;
  price: number;
  currency: string; // Aggiunto 'currency' per il contesto globale
}

function displayInfo<T extends { name: string }>(item: T): void {
  console.log(`Name: ${item.name}`);
}

let person: Person = { name: "Alice", age: 30, country: "USA" };
let product: Product = { id: 1, name: "Laptop", price: 1200, currency: "USD" };

displayInfo(person); // Output: Name: Alice
displayInfo(product); // Output: Name: Laptop

In questo esempio, la funzione displayInfo accetta un oggetto di tipo T che deve avere una proprietà name di tipo stringa. La clausola extends { name: string } è un vincolo (constraint), che specifica i requisiti minimi per il parametro di tipo T. Ciò garantisce che la funzione possa accedere in sicurezza alla proprietà name.

Utilizzo Avanzato dei Generics

I generics di TypeScript offrono funzionalità più avanzate che consentono di creare codice ancora più flessibile e potente. Esploriamo alcune di queste funzionalità:

Parametri di Tipo Multipli

È possibile definire funzioni o classi con parametri di tipo multipli:


function merge<T, U>(obj1: T, obj2: U): T & U {
  return { ...obj1, ...obj2 };
}

interface Name {
  firstName: string;
}

interface Age {
  age: number;
}

const person: Name = { firstName: "Bob" };
const details: Age = { age: 42 };

const merged = merge(person, details);
console.log(merged.firstName); // Output: Bob
console.log(merged.age); // Output: 42

La funzione merge accetta due oggetti di tipo T e U e restituisce un nuovo oggetto che contiene le proprietà di entrambi gli oggetti. Questo è un modo potente per combinare dati da fonti diverse.

Vincoli Generici (Constraints)

Come mostrato in precedenza, i vincoli consentono di limitare i tipi che possono essere utilizzati con un parametro di tipo generico. Ciò garantisce che il codice generico possa operare in sicurezza sui tipi specificati.


interface Lengthwise {
  length: number;
}

function loggingIdentity<T extends Lengthwise>(arg: T): T {
  console.log(arg.length);
  return arg;
}

loggingIdentity([1, 2, 3]); // Output: 3
loggingIdentity("hello"); // Output: 5
// loggingIdentity(123); // Errore: l'argomento di tipo 'number' non è assegnabile al parametro di tipo 'Lengthwise'.

La funzione loggingIdentity accetta un argomento di tipo T che deve avere una proprietà length di tipo numero. Ciò garantisce che la funzione possa accedere in sicurezza alla proprietà length.

Classi Generiche

I generics possono essere utilizzati anche con le classi:


class DataStorage<T> {
  private data: T[] = [];

  addItem(item: T) {
    this.data.push(item);
  }

  removeItem(item: T) {
    this.data = this.data.filter(d => d !== item);
  }

  getItems(): T[] {
    return [...this.data];
  }
}

const textStorage = new DataStorage<string>();
textStorage.addItem("apple");
textStorage.addItem("banana");
textStorage.removeItem("apple");
console.log(textStorage.getItems()); // Output: [ 'banana' ]

const numberStorage = new DataStorage<number>();
numberStorage.addItem(1);
numberStorage.addItem(2);
numberStorage.removeItem(1);
console.log(numberStorage.getItems()); // Output: [ 2 ]

La classe DataStorage può memorizzare dati di qualsiasi tipo T. Ciò consente di creare strutture dati riutilizzabili e sicure dal punto di vista dei tipi.

Interfacce Generiche

Le interfacce generiche sono utili per definire contratti che possono funzionare con tipi diversi. Ad esempio:


interface Result<T, E> {
  success: boolean;
  data?: T;
  error?: E;
}

interface User {
  id: number;
  username: string;
  email: string;
}

interface ErrorMessage {
  code: number;
  message: string;
}

function fetchUser(id: number): Result<User, ErrorMessage> {
  if (id === 1) {
    return { success: true, data: { id: 1, username: "john.doe", email: "john.doe@example.com" } };
  } else {
    return { success: false, error: { code: 404, message: "User not found" } };
  }
}

const userResult = fetchUser(1);
if (userResult.success) {
  console.log(userResult.data.username);
} else {
  console.log(userResult.error.message);
}

L'interfaccia Result definisce una struttura generica per rappresentare l'esito di un'operazione. Può contenere dati di tipo T o un errore di tipo E. Questo è un pattern comune per la gestione di operazioni asincrone o operazioni che possono fallire.

Tipi di Utilità (Utility Types) e Generics

TypeScript fornisce diversi tipi di utilità integrati che funzionano bene con i generics. Questi tipi di utilità possono aiutarti a trasformare e manipolare i tipi in modi potenti.

Partial<T>

Partial<T> rende tutte le proprietà del tipo T opzionali:


interface Person {
  name: string;
  age: number;
}

type PartialPerson = Partial<Person>;

const partialPerson: PartialPerson = { name: "Alice" }; // Valido

Readonly<T>

Readonly<T> rende tutte le proprietà del tipo T di sola lettura (readonly):


interface Person {
  name: string;
  age: number;
}

type ReadonlyPerson = Readonly<Person>;

const readonlyPerson: ReadonlyPerson = { name: "Bob", age: 42 };
// readonlyPerson.age = 43; // Errore: Impossibile assegnare a 'age' perché è una proprietà di sola lettura.

Pick<T, K>

Pick<T, K> seleziona un insieme di proprietà K dal tipo T:


interface Person {
  name: string;
  age: number;
  email: string;
}

type NameAndAge = Pick<Person, "name" | "age">;

const nameAndAge: NameAndAge = { name: "Charlie", age: 28 };

Omit<T, K>

Omit<T, K> rimuove un insieme di proprietà K dal tipo T:


interface Person {
  name: string;
  age: number;
  email: string;
}

type PersonWithoutEmail = Omit<Person, "email">;

const personWithoutEmail: PersonWithoutEmail = { name: "David", age: 35 };

Record<K, T>

Record<K, T> crea un tipo con chiavi K e valori di tipo T:


type CountryCodes = "US" | "CA" | "UK" | "DE" | "FR" | "JP" | "CN" | "IN" | "BR" | "AU"; // Lista estesa per contesto globale
type Currency = "USD" | "CAD" | "GBP" | "EUR" | "JPY" | "CNY" | "INR" | "BRL" | "AUD"; // Lista estesa per contesto globale

type CurrencyMap = Record<CountryCodes, Currency>;

const currencyMap: CurrencyMap = {
  "US": "USD",
  "CA": "CAD",
  "UK": "GBP",
  "DE": "EUR",
  "FR": "EUR",
  "JP": "JPY",
  "CN": "CNY",
  "IN": "INR",
  "BR": "BRL",
  "AU": "AUD",
};

Tipi Mappati (Mapped Types)

I tipi mappati consentono di trasformare i tipi esistenti iterando sulle loro proprietà. Questo è un modo potente per creare nuovi tipi basati su quelli esistenti. Ad esempio, è possibile creare un tipo che renda tutte le proprietà di un altro tipo di sola lettura:


interface Person {
  name: string;
  age: number;
}

type ReadonlyPerson = {
  readonly [K in keyof Person]: Person[K];
};

const readonlyPerson: ReadonlyPerson = { name: "Eve", age: 25 };
// readonlyPerson.age = 26; // Errore: Impossibile assegnare a 'age' perché è una proprietà di sola lettura.

In questo esempio, [K in keyof Person] itera su tutte le chiavi dell'interfaccia Person, e Person[K] accede al tipo di ciascuna proprietà. La parola chiave readonly rende ogni proprietà di sola lettura.

Tipi Condizionali (Conditional Types)

I tipi condizionali consentono di definire tipi basati su condizioni. Questo è un modo potente per creare tipi che si adattano a scenari diversi.


type NonNullable<T> = T extends null | undefined ? never : T;

type MaybeString = string | null | undefined;
type StringType = NonNullable<MaybeString>; // string

function getValue<T>(value: T): NonNullable<T> {
  if (value == null) { // Gestisce sia null che undefined
    throw new Error("Il valore non può essere nullo o indefinito");
  }
  return value as NonNullable<T>;
}

try {
  const validValue = getValue("hello");
  console.log(validValue.toUpperCase()); // Output: HELLO

  const invalidValue = getValue(null); // Questo lancerà un errore
  console.log(invalidValue); // Questa riga non verrà raggiunta
} catch (error: any) {
  console.error(error.message); // Output: Il valore non può essere nullo o indefinito
}

In questo esempio, il tipo NonNullable<T> controlla se T è null o undefined. Se lo è, restituisce never, il che significa che il tipo non è consentito. Altrimenti, restituisce T. Ciò consente di creare tipi che sono garantiti non essere nullabili.

Best Practice per l'Uso dei Generics

Ecco alcune best practice da tenere a mente quando si utilizzano i generics:

Esempi in un Contesto Globale

Consideriamo alcuni esempi di come i generics possono essere utilizzati in un contesto globale:

Conversione di Valuta


interface ConversionRate {
  rate: number;
  fromCurrency: string;
  toCurrency: string;
}

function convertCurrency<T extends ConversionRate>(amount: number, rate: T): number {
  return amount * rate.rate;
}

const usdToEurRate: ConversionRate = { rate: 0.85, fromCurrency: "USD", toCurrency: "EUR" };
const amountInUSD = 100;
const amountInEUR = convertCurrency(amountInUSD, usdToEurRate);
console.log(`${amountInUSD} USD equivalgono a ${amountInEUR} EUR`); // Output: 100 USD equivalgono a 85 EUR

Formattazione della Data


interface DateFormatOptions {
  locale: string;
  options: Intl.DateTimeFormatOptions;
}

function formatDate<T extends DateFormatOptions>(date: Date, format: T): string {
  return date.toLocaleDateString(format.locale, format.options);
}

const currentDate = new Date();

const usDateFormat: DateFormatOptions = { locale: "en-US", options: { year: 'numeric', month: 'long', day: 'numeric' } };
const germanDateFormat: DateFormatOptions = { locale: "de-DE", options: { year: 'numeric', month: 'long', day: 'numeric' } };
const japaneseDateFormat: DateFormatOptions = { locale: "ja-JP", options: { year: 'numeric', month: 'long', day: 'numeric' } };

console.log("Data USA: " + formatDate(currentDate, usDateFormat));
console.log("Data Tedesca: " + formatDate(currentDate, germanDateFormat));
console.log("Data Giapponese: " + formatDate(currentDate, japaneseDateFormat));

Servizio di Traduzione


interface Translation {
  [key: string]: string; // Consente chiavi di lingua dinamiche
}

interface LanguageData<T extends Translation> {
  languageCode: string;
  translations: T;
}

const englishTranslations: Translation = {
  "hello": "Hello",
  "goodbye": "Goodbye",
  "welcome": "Welcome to our website!"
};

const spanishTranslations: Translation = {
  "hello": "Hola",
  "goodbye": "Adiós",
  "welcome": "¡Bienvenido a nuestro sitio web!"
};

const frenchTranslations: Translation = {
  "hello": "Bonjour",
  "goodbye": "Au revoir",
  "welcome": "Bienvenue sur notre site web !"
};


const languageData: LanguageData<typeof englishTranslations>[] = [
  {languageCode: "en", translations: englishTranslations },
  {languageCode: "es", translations: spanishTranslations },
  {languageCode: "fr", translations: frenchTranslations}
];

function translate<T extends Translation>(key: string, languageCode: string, languageData: LanguageData<T>[]): string {
  const lang = languageData.find(lang => lang.languageCode === languageCode);
  if (!lang) {
    return `Traduzione per ${key} in ${languageCode} non trovata.`;
  }
  return lang.translations[key] || `Traduzione per ${key} non trovata.`;
}

console.log(translate("hello", "en", languageData)); // Output: Hello
console.log(translate("hello", "es", languageData)); // Output: Hola
console.log(translate("welcome", "fr", languageData)); // Output: Bienvenue sur notre site web !
console.log(translate("missingKey", "de", languageData)); // Output: Traduzione per missingKey in de non trovata.

Conclusione

I generics di TypeScript sono uno strumento potente per scrivere codice riutilizzabile e sicuro dal punto di vista dei tipi, in grado di funzionare con tipi di dati complessi. Comprendendo la sintassi di base, le funzionalità avanzate e le best practice dei generics, è possibile migliorare significativamente la qualità e la manutenibilità delle proprie applicazioni TypeScript. Nello sviluppo di applicazioni per un pubblico globale, i generics possono aiutare a gestire formati di dati e convenzioni culturali diversi, garantendo un'esperienza utente fluida per tutti.